/*
 * Copyright 2013 Sigurd Randoll.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package de.digiway.rapidbreeze.server.infrastructure.objectstorage;

import de.digiway.rapidbreeze.shared.util.ObjectHelper;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.commons.lang3.ObjectUtils;

/**
 * A very simple object storage to persist and retrieve annotated objects. It
 * relies on simple plain text files storing information about object
 * properties. It reads annotation similar to JPA for the entities to
 * manage.<p/>
 *
 * TODO: Annotation usage example
 *
 * @author Sigurd
 */
public class ObjectStorage {

    private FileSystem fileSystem;
    private Path root;
    private PlainStorageMap classMap;
    private static final String CLASSMAP_FILENAME = "classes.idx";
    private static final Logger LOG = Logger.getLogger(ObjectStorage.class.getName());

    /**
     * Creates a new instance of the object storage using the given location as
     * storage point.
     *
     * @param storageLocation
     */
    public ObjectStorage(Path storageLocation) {
        if (storageLocation == null) {
            throw new IllegalArgumentException("Cannot use path:" + storageLocation + " as storage location for objects.");
        }

        try {
            if (!Files.exists(storageLocation)) {
                Files.createDirectories(storageLocation);
            }
            this.root = storageLocation;
            this.fileSystem = FileSystems.getDefault();
            this.classMap = getStorageMap(CLASSMAP_FILENAME);
        } catch (IOException ex) {
            throw new IllegalStateException(ex);
        }
    }

    /**
     * Closes this object storage. After the storage is closed, objects cannot
     * be persisted or retrieved anymore.
     */
    public void close() {
        try {
            classMap.commit();
            if (!FileSystems.getDefault().equals(fileSystem)) {
                fileSystem.close();
            }
        } catch (IOException ex) {
            throw new IllegalStateException(ex);
        }
    }

    /**
     * Persists the given object. The object must be annotated correctly. See
     * class description.
     *
     * @param obj
     */
    public void persist(Object obj) {
        if (!ObjectStorageHelper.isEntity(obj)) {
            throw new IllegalArgumentException("The given object: " + obj + " is not a valid entity.");
        }
        if (!ObjectStorageHelper.hasValidIdField(obj)) {
            throw new IllegalArgumentException("The given object: " + obj + " does not have a valid identifier field.");
        }

        // Check if entity has a id. If not, assign a unique identifier:
        if (!ObjectStorageHelper.hasValidId(obj)) {
            ObjectStorageHelper.generateId(obj);
        }

        PlainStorageMap objectMap = null;
        PlainStorageMap classIndexMap = null;

        try {
            String tablename = ObjectStorageHelper.getEntityTable(obj);
            if (!classMap.containsKey(tablename)) {
                classMap.put(tablename, tablename + ".idx");
            }

            String classIndexFilename = classMap.get(tablename);
            classIndexMap = getStorageMap(classIndexFilename);
            String id = ObjectStorageHelper.getId(obj);
            if (!classIndexMap.containsKey(id)) {
                classIndexMap.put(id, tablename + "_" + id);
            }

            String objectMapFilename = classIndexMap.get(id);
            objectMap = getStorageMap(objectMapFilename);
            Map<String, String> fields = getPersistedFields(obj);
            objectMap.putAll(fields);

            objectMap.commit();
            classIndexMap.commit();
            classMap.commit();
        } catch (Exception ex) {
            if (objectMap != null) {
                objectMap.rollback();
            }
            if (classIndexMap != null) {
                classIndexMap.rollback();
            }
            classMap.rollback();
            throw ex;
        }
    }

    /**
     * Loads all existing entities of the given class.
     *
     * @param <T>
     * @param clazz
     * @return List of entities which might be empty.
     * @throws IllegalArgumentException if the given class is not a valid entity
     * class.
     */
    public <T> List<T> load(Class<T> clazz) {
        if (!ObjectStorageHelper.isEntity(clazz)) {
            throw new IllegalArgumentException("The given class: " + clazz + " is not a valid entity class.");
        }

        List<T> entities = new ArrayList<>();
        String tablename = ObjectStorageHelper.getEntityTable(clazz);
        if (classMap.containsKey(tablename)) {
            String classIndexFilename = classMap.get(tablename);
            PlainStorageMap classIndexMap = getStorageMap(classIndexFilename);
            for (Map.Entry<String, String> entrySet : classIndexMap.entrySet()) {
                PlainStorageMap storageMap = getStorageMap(entrySet.getValue());
                entities.add(loadInstance(clazz, storageMap));
            }
        }
        return entities;
    }

    /**
     * Loads the entity of the given class with the given identifier.
     *
     * @param <T>
     * @param clazz
     * @param identifier
     * @return entity
     * @throws IllegalArgumentException if the class and the identifier do not
     * exist or if the given class is not a valid entity class.
     * @throws IllegalStateException if there is a configuration issue of the
     * entity class.
     */
    public <T> T load(Class<T> clazz, String identifier) {
        if (!ObjectStorageHelper.isEntity(clazz)) {
            throw new IllegalArgumentException("The given class: " + clazz + " is not a valid entity class.");
        }

        String tablename = ObjectStorageHelper.getEntityTable(clazz);
        if (classMap.containsKey(tablename)) {
            String classIndexFilename = classMap.get(tablename);
            PlainStorageMap classIndexMap = getStorageMap(classIndexFilename);
            if (classIndexMap.containsKey(identifier)) {
                PlainStorageMap objectMap = getStorageMap(classIndexMap.get(identifier));
                return loadInstance(clazz, objectMap);
            }
        }
        throw new IllegalArgumentException("Cannot retrieve object of class " + clazz + " with identifier " + identifier);
    }

    /**
     * Returns a new {@linkplain ObjectStorageFilter} instance to perform filter
     * operations on the given class.
     *
     * @param <T>
     * @param clazz
     * @return
     */
    public <T> ObjectStorageFilter<T> createFilter(Class<T> clazz) {
        return new ObjectStorageFilter<>(clazz);
    }

    /**
     * Loads all instances which apply to the given filter.
     *
     * @param <T>
     * @param filter
     * @return
     */
    public <T> List<T> load(final ObjectStorageFilter filter) {
        List<T> list = load(filter.getClazz());

        // Apply order by filter criteria:
        if (filter.getOrderByProperty() != null) {
            Collections.sort(list, new Comparator<T>() {
                @Override
                public int compare(T o1, T o2) {
                    Comparable value1 = ObjectStorageHelper.getProperty(o1, filter.getOrderByProperty());
                    Comparable value2 = ObjectStorageHelper.getProperty(o2, filter.getOrderByProperty());
                    return ObjectUtils.compare(value1, value2);
                }
            });
            if (!filter.isOrderByAscending()) {
                Collections.reverse(list);
            }
        }

        return list;
    }

    private <T> T loadInstance(Class<T> clazz, Map<String, String> properties) {
        try {
            // Create new object of class using default constructor:
            Constructor<T> constructor = clazz.getDeclaredConstructor();
            if (!constructor.isAccessible()) {
                constructor.setAccessible(true);
            }
            T instance = constructor.newInstance();

            // Iterate over all existing properties and fill fields of new object:
            for (Map.Entry<String, String> entrySet : properties.entrySet()) {
                for (Field field : clazz.getDeclaredFields()) {
                    if (field.getName().equals(entrySet.getKey())) {
                        if (!field.isAccessible()) {
                            field.setAccessible(true);
                        }
                        field.set(instance, stringToObject(field.getType(), entrySet.getValue()));
                        break;
                    }
                }
            }
            return instance;
        } catch (NoSuchMethodException ex) {
            throw new IllegalStateException("Cannot find default constructor of class " + clazz, ex);
        } catch (SecurityException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException ex) {
            throw new IllegalStateException("Error during value assignment.", ex);
        }
    }

    /**
     * Removes the given entity of the storagea.
     *
     * @param obj
     * @throws IllegalArgumentException if the given object is not a valid
     * entity or does not exist in the storage.
     */
    public void remove(Object obj) {
        if (!ObjectStorageHelper.isEntity(obj)) {
            throw new IllegalArgumentException("The given object: " + obj + " is not a valid entity.");
        }
        if (!ObjectStorageHelper.hasValidId(obj)) {
            throw new IllegalArgumentException("The given object: " + obj + " does not have a valid identifier.");
        }

        String tablename = ObjectStorageHelper.getEntityTable(obj);
        String identifier = ObjectStorageHelper.getId(obj);

        if (classMap.containsKey(tablename)) {
            String classIndexFilename = classMap.get(tablename);
            PlainStorageMap classIndexMap = getStorageMap(classIndexFilename);
            if (classIndexMap.containsKey(identifier)) {
                try {
                    String objectMapFilename = classIndexMap.remove(identifier);
                    Path objectMapPath = fileSystem.getPath(root.toString(), objectMapFilename);
                    classIndexMap.commit();
                    Files.deleteIfExists(objectMapPath);
                } catch (IOException ex) {
                    LOG.log(Level.WARNING, "Cannot remove object property file. Leaving it there.", ex);
                } catch (Exception ex) {
                    classIndexMap.rollback();
                    throw ex;
                }
                return;
            }
        }
        throw new IllegalArgumentException("Cannot find object: " + obj + " in storage.");
    }

    /**
     * Removes all entities of the given class. If there is no entity of the
     * given class existing in the storage, the method returns without any
     * action.
     *
     * @param clazz
     */
    public void removeAll(Class<?> clazz) {
        if (!ObjectStorageHelper.isEntity(clazz)) {
            throw new IllegalArgumentException("The given class: " + clazz + " is not a valid entity class.");
        }

        String tablename = ObjectStorageHelper.getEntityTable(clazz);

        if (classMap.containsKey(tablename)) {
            String classIndexFilename = classMap.get(tablename);
            PlainStorageMap classIndexMap = getStorageMap(classIndexFilename);
            Iterator<Map.Entry<String, String>> it = classIndexMap.entrySet().iterator();
            try {
                while (it.hasNext()) {
                    String filename = it.next().getValue();
                    it.remove();
                    Path path = fileSystem.getPath(root.toString(), filename);
                    Files.deleteIfExists(path);
                }
                classIndexMap.commit();
            } catch (IOException ex) {
                LOG.log(Level.WARNING, "Cannot remove object property file. Leaving it there.", ex);
            } catch (Exception ex) {
                classIndexMap.rollback();
                throw ex;
            }
        }
    }

    /**
     * Checks if the entity of the given class with the given identifier exists.
     *
     * @param clazz
     * @param identifier
     * @return
     */
    public boolean exists(Class<? extends Object> clazz, String identifier) {
        String tablename = ObjectStorageHelper.getEntityTable(clazz);
        if (classMap.containsKey(tablename)) {
            String classIndexFilename = classMap.get(tablename);
            PlainStorageMap classIndexMap = getStorageMap(classIndexFilename);
            if (classIndexMap.containsKey(identifier)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Checks if the given object is existing in the storage.
     *
     * @param obj
     * @return
     */
    public boolean exists(Object obj) {
        String id = ObjectStorageHelper.getId(obj);
        return exists(obj.getClass(), id);
    }

    private PlainStorageMap getStorageMap(String filename) {
        return new PlainStorageMap(fileSystem.getPath(root.toString(), filename));
    }

    private Map<String, String> getPersistedFields(Object obj) {
        Map<String, String> fields = new HashMap<>();
        for (Field field : obj.getClass().getDeclaredFields()) {
            if (isFieldPersistable(field)) {
                try {
                    if (!field.isAccessible()) {
                        field.setAccessible(true);
                    }
                    Object value = field.get(obj);
                    fields.put(field.getName(), objectToString(value));
                } catch (IllegalArgumentException | IllegalAccessException ex) {
                    throw new IllegalArgumentException("Cannot retrieve fields to persist.", ex);
                }
            }
        }
        return fields;
    }

    private boolean isFieldPersistable(Field field) {
        if (field.isAnnotationPresent(Id.class) || field.isAnnotationPresent(Column.class)) {
            return true;
        }
        return false;
    }

    private String objectToString(Object obj) {
        if (ObjectStorageHelper.isEntity(obj)) {
            persist(obj);
            String referenceId = ObjectStorageHelper.getId(obj);
            return referenceId;
        } else {
            return ObjectHelper.objectToString(obj);
        }
    }

    private Object stringToObject(Class<?> clazz, String value) {
        if (ObjectStorageHelper.isEntity(clazz)) {
            if (exists(clazz, value)) {
                return load(clazz, value);
            }
            return null;
        } else {
            return ObjectHelper.stringToObject(clazz, value);
        }
    }
}
